#!/usr/bin/env ruby

# -------------------------------------------------------------------------- #
# Copyright 2002-2023, OpenNebula Project, OpenNebula Systems                #
#                                                                            #
# Licensed under the Apache License, Version 2.0 (the "License"); you may    #
# not use this file except in compliance with the License. You may obtain    #
# a copy of the License at                                                   #
#                                                                            #
# http://www.apache.org/licenses/LICENSE-2.0                                 #
#                                                                            #
# Unless required by applicable law or agreed to in writing, software        #
# distributed under the License is distributed on an "AS IS" BASIS,          #
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.   #
# See the License for the specific language governing permissions and        #
# limitations under the License.                                             #
#--------------------------------------------------------------------------- #

ONE_LOCATION = ENV['ONE_LOCATION']

if !ONE_LOCATION
    RUBY_LIB_LOCATION = '/usr/lib/one/ruby'
    GEMS_LOCATION     = '/usr/share/one/gems'
else
    RUBY_LIB_LOCATION = ONE_LOCATION + '/lib/ruby'
    GEMS_LOCATION     = ONE_LOCATION + '/share/gems'
end

# %%RUBYGEMS_SETUP_BEGIN%%
if File.directory?(GEMS_LOCATION)
    real_gems_path = File.realpath(GEMS_LOCATION)
    if !defined?(Gem) || Gem.path != [real_gems_path]
        $LOAD_PATH.reject! {|l| l =~ /vendor_ruby/ }

        # Suppress warnings from Rubygems
        # https://github.com/OpenNebula/one/issues/5379
        begin
            verb = $VERBOSE
            $VERBOSE = nil
            require 'rubygems'
            Gem.use_paths(real_gems_path)
        ensure
            $VERBOSE = verb
        end
    end
end
# %%RUBYGEMS_SETUP_END%%

$LOAD_PATH << RUBY_LIB_LOCATION
$LOAD_PATH << RUBY_LIB_LOCATION + '/cli'

require 'command_parser'
require 'one_helper/oneuser_helper'
require 'one_helper/onequota_helper'

require 'uri'

CommandParser::CmdParser.new(ARGV) do
    usage '`oneuser` <command> [<args>] [<options>]'
    version OpenNebulaHelper::ONE_VERSION

    helper = OneUserHelper.new

    before_proc do
        if ![:key].include?(@comm_name)
            begin
                helper.set_client(options)
            rescue StandardError => e
                STDERR.puts e.message

                if e.message != OpenNebula::Client::NO_ONE_AUTH_ERROR
                    STDERR.puts e.backtrace
                end

                exit 1
            end
        end
    end

    ########################################################################
    # Global Options
    ########################################################################
    set :option, CommandParser::OPTIONS + OpenNebulaHelper::CLIENT_OPTIONS

    list_options  = CLIHelper::OPTIONS
    list_options += OpenNebulaHelper::FORMAT
    list_options << OpenNebulaHelper::NUMERIC
    list_options << OpenNebulaHelper::DESCRIBE

    READ_FILE = {
        :name => 'read_file',
        :short => '-r',
        :large => '--read-file',
        :description => 'Read password from file'
    }

    SHA256 = {
        :name => 'sha256',
        :large => '--sha256',
        :description => 'The password will be hashed using the sha256 algorithm'
    }

    SSH = {
        :name => 'ssh',
        :large => '--ssh',
        :description => 'SSH Auth system',
        :proc => lambda {|_, options|
            options[:driver] = OpenNebula::User::SSH_AUTH
        }
    }

    X509 = {
        :name => 'x509',
        :large => '--x509',
        :description => 'x509 Auth system for x509 certificates',
        :proc => lambda {|_, options|
            options[:driver] = OpenNebula::User::X509_AUTH
        }
    }

    X509_PROXY = {
        :name => 'x509_proxy',
        :large => '--x509_proxy',
        :description => 'x509 Auth system based on x509 proxy certificates',
        :proc => lambda {|_, options|
            options[:driver] = OpenNebula::User::X509_PROXY_AUTH
        }
    }

    KEY = {
        :name => 'key',
        :short => '-k path_to_private_key_pem',
        :large => '--key path_to_private_key_pem',
        :format => String,
        :description => 'Path to the Private Key of the User'
    }

    CERT = {
        :name => 'cert',
        :short => '-c path_to_user_cert_pem',
        :large => '--cert path_to_user_cert_pem',
        :format => String,
        :description => 'Path to the Certificate of the User'
    }

    PROXY = {
        :name => 'proxy',
        :large => '--proxy path_to_user_proxy_pem',
        :format => String,
        :description => 'Path to the user proxy certificate'
    }

    TIME = {
        :name => 'time',
        :large => '--time x',
        :format => Integer,
        :description => 'Token duration in seconds, defaults to 36000 (10 h). '\
                        'To reset the token set time to 0.' \
                        'To generate a non-expiring token use -1'\
                        ' (not valid for ssh and x509 tokens). '\
    }

    DRIVER = {
        :name => 'driver',
        :large => '--driver driver',
        :format => String,
        :description => 'Driver to authenticate this user'
    }

    FORCE = {
        :name => 'force',
        :large => '--force',
        :description => 'Force one_auth file rewrite'
    }

    TOKEN = {
        :name => 'token',
        :large => '--token token_hint',
        :format => String,
        :description => 'The Token to be loaded.'
    }

    GROUP = {
        :name => 'group',
        :large => '--group id|name',
        :description => 'Effective GID to use with this token.',
        :format => String,
        :proc => lambda {|o, _|
            OpenNebulaHelper.rname_to_id(o, 'GROUP')
        }
    }

    GROUP_CREATE = {
        :name => 'group',
        :large => '--group id|name',
        :description => 'Comma-separated list of Groups for the new User. '\
                        'The first Group will be the main one.',
        :format => String,
        :proc => lambda {|o, _|
            gids = o.split(',').map do |g|
                id = OpenNebulaHelper.rname_to_id(g, 'GROUP')

                if id[0] == -1
                    puts id[1]
                    exit(-1)
                end

                id[1]
            end

            [0, gids]
        }
    }

    GLOBAL = {
        :name => 'global',
        :large => '--global',
        :description => 'Find a global Token.'
    }

    STDIN_PASSWORD = {
        :name => 'stdin_password',
        :large => '--stdin_password',
        :description => 'enable stdin password'
    }

    auth_options = [READ_FILE, SHA256, SSH, X509, KEY, CERT, DRIVER]

    create_options = auth_options.clone.unshift(GROUP_CREATE)

    login_options  = [SSH,
                      X509,
                      X509_PROXY,
                      KEY,
                      CERT,
                      PROXY,
                      TIME,
                      FORCE,
                      GROUP,
                      STDIN_PASSWORD]

    set_options = [TOKEN, GLOBAL]

    ########################################################################
    # Formatters for arguments
    ########################################################################
    set :format, :groupid, OpenNebulaHelper.rname_to_id_desc('GROUP') do |arg|
        OpenNebulaHelper.rname_to_id(arg, 'GROUP')
    end

    set :format, :userid, OneUserHelper.to_id_desc do |arg|
        helper.to_id(arg)
    end

    set :format, :userid_list, OneUserHelper.list_to_id_desc do |arg|
        helper.list_to_id(arg)
    end

    set :format, :password, OneUserHelper.password_to_str_desc do |arg|
        OneUserHelper.password_to_str(arg, options)
    end

    ########################################################################
    # Commands
    ########################################################################

    create_desc = <<-EOT.unindent
        Creates a new User
        Examples:
          oneuser create my_user my_password
          oneuser create my_user -r /tmp/mypass
          oneuser create my_user my_password --group users,102,testers
          oneuser create my_user --ssh --key /tmp/id_rsa
          oneuser create my_user --ssh -r /tmp/public_key
          oneuser create my_user --x509 --cert /tmp/my_cert.pem
          oneuser create my_user --driver ldap
    EOT

    command :create, create_desc, :username, [:password, nil],
            :options => create_options do
        if args[1]
            pass = args[1]
        else
            rc = helper.password(options)
            if rc.first.zero?
                pass = rc[1]
            else
                exit_with_code(*rc)
            end
        end

        driver = options[:driver] || OpenNebula::User::CORE_AUTH

        gids = options[:group] || []

        helper.create_resource(options) do |user|
            user.allocate(args[0], pass, driver, gids)
        end
    end

    update_desc = <<-EOT.unindent
        Update the template contents. If a path is not provided the editor will
        be launched to modify the current content.
    EOT

    command :update, update_desc, :userid, [:file, nil],
            :options => OpenNebulaHelper::APPEND do
        helper.perform_action(args[0], options, 'modified') do |obj|
            if options[:append]
                str = OpenNebulaHelper.append_template(args[0], obj, args[1])
            else
                str = OpenNebulaHelper.update_template(args[0], obj, args[1])
            end

            helper.set_client(options)
            obj = helper.retrieve_resource(obj.id)

            obj.update(str, options[:append])
        end
    end

    quota_desc = <<-EOT.unindent
        Set the quota limits for the user. If a path is not provided the editor
        will be launched to modify the current quotas.
    EOT

    command :quota, quota_desc, :userid, [:file, nil] do
        helper.perform_action(args[0], options, 'modified') do |user|
            rc = user.info

            if OpenNebula.is_error?(rc)
                puts rc.message
                exit(-1)
            end

            str = OneQuotaHelper.set_quota(user, args[1])

            helper.set_client(options)
            user = helper.retrieve_resource(user.id)

            rc = user.set_quota(str)

            if OpenNebula.is_error?(rc)
                puts rc.message
                exit(-1)
            end
        end
    end

    batchquota_desc = <<-EOT.unindent
        Sets the quota limits in batch for various users. If a path is not
        provided the editor will be launched to create new quotas.
    EOT

    command :batchquota, batchquota_desc, [:range, :userid_list],
            [:file, nil] do
        batch_str = OneQuotaHelper.get_batch_quota(args[1])

        helper.set_client(options)
        helper.perform_actions(args[0], options, 'modified') do |user|
            str = OneQuotaHelper.merge_quota(user, batch_str)

            if OpenNebula.is_error?(str)
                str
            else
                user.set_quota(str)
            end
        end
    end

    defaultquota_desc = <<-EOT.unindent
        Sets the default quota limits for the users. If a path is not provided
        the editor will be launched to modify the current default quotas.
    EOT

    command :defaultquota, defaultquota_desc, [:file, nil] do
        system = System.new(OneUserHelper.get_client(options))

        default_quotas = system.get_user_quotas

        if OpenNebula.is_error?(default_quotas)
            puts default_quotas.message
            exit(-1)
        end

        str = OneQuotaHelper.set_quota(default_quotas, args[0], true)

        system = System.new(OneUserHelper.get_client(options, true))
        rc = system.set_user_quotas(str)

        if OpenNebula.is_error?(rc)
            puts rc.message
            exit(-1)
        end

        exit 0
    end

    umask_desc = <<-EOT.unindent
        Changes the umask used to create the default permissions. In a similar
        way to the Unix umask command, the expected value is a three-digit
        base-8 number. Each digit is a mask that disables permissions for the
        owner, group and other, respectively.

        If mask is not given, or if it is an empty string, the umask will
        be unset
    EOT

    command :umask, umask_desc, [:range, :userid_list], [:mask, nil] do
        helper.perform_actions(args[0], options,
                               'umask changed') do |user|
            rc = user.info

            if OpenNebula.is_error?(rc)
                puts rc.message
                exit(-1)
            end

            user.delete_element('/USER/TEMPLATE/UMASK')

            tmp_str = user.template_str

            if !args[1].nil? && args[1] != ''
                tmp_str << "\nUMASK = #{args[1]}"
            end

            user.update(tmp_str)
        end
    end

    login_desc = <<-EOT.unindent
        Alias of token-create.
    EOT

    command :login, login_desc, [:username, nil], :options => login_options do
        helper.token_create(args, options)
    end

    key_desc = <<-EOT.unindent
        Shows a public key from a private SSH key. Use it as password
        for the SSH authentication mechanism.
    EOT

    command :key, key_desc, :options => [KEY] do
        require 'opennebula/ssh_auth'

        options[:key] ||= Dir.home + '/.ssh/id_rsa'

        begin
            sshauth = SshAuth.new(:private_key => options[:key])
        rescue StandardError => e
            exit_with_code(-1, e.message)
        end

        puts sshauth.password
        exit_with_code 0
    end

    delete_desc = <<-EOT.unindent
        Deletes the given User
    EOT

    command :delete, delete_desc, [:range, :userid_list] do
        helper.perform_actions(args[0], options, 'deleted') do |user|
            user.delete
        end
    end

    passwd_desc = <<-EOT.unindent
        Changes the given User's password
    EOT

    command :passwd, passwd_desc, [:userid], [:password, nil],
            :options => auth_options do
        if args[1]
            pass = args[1]
        else
            rc = helper.password(options)
            if rc.first.zero?
                pass = rc[1]
            else
                exit_with_code(*rc)
            end
        end

        helper.perform_action(args[0], options, 'Password changed') do |user|
            user.passwd(pass)
        end
    end

    chgrp_desc = <<-EOT.unindent
        Changes the User's primary group
    EOT

    command :chgrp, chgrp_desc, [:range, :userid_list], :groupid do
        helper.perform_actions(args[0], options, 'Group changed') do |user|
            user.chgrp(args[1].to_i)
        end
    end

    addgroup_desc = <<-EOT.unindent
        Adds the User to a secondary group
    EOT

    command :addgroup, addgroup_desc, [:range, :userid_list], :groupid do
        gid = args[1]

        helper.perform_actions(args[0], options, 'group added') do |user|
            user.addgroup(gid)
        end
    end

    delgroup_desc = <<-EOT.unindent
        Removes the User from a secondary group
    EOT

    command :delgroup, delgroup_desc, [:range, :userid_list], :groupid do
        gid = args[1]

        helper.perform_actions(args[0], options, 'group deleted') do |user|
            user.delgroup(gid)
        end
    end

    chauth_desc = <<-EOT.unindent
        Changes the User's auth driver and its password (optional)
        Examples:
          oneuser chauth my_user core
          oneuser chauth my_user core new_password
          oneuser chauth my_user core -r /tmp/mypass
          oneuser chauth my_user --ssh --key /home/oneadmin/.ssh/id_rsa
          oneuser chauth my_user --ssh -r /tmp/public_key
          oneuser chauth my_user --x509 --cert /tmp/my_cert.pem
    EOT

    command :chauth, chauth_desc, :userid, [:auth, nil], [:password, nil],
            :options => auth_options do
        if options[:driver]
            driver = options[:driver]
        elsif args[1]
            driver = args[1]
        else
            exit_with_code 0, 'An Auth driver should be specified'
        end

        if args[2]
            pass = args[2]
        else
            rc = helper.password(options)
            if rc.first.zero?
                pass = rc[1]
            else
                pass = ''
            end
        end

        helper.perform_action(args[0],
                              options,
                              'Auth driver and password changed') do |user|
            user.chauth(driver, pass)
        end
    end

    list_desc = <<-EOT.unindent
        Lists Users in the pool. #{OneUserHelper.list_layout_help}
    EOT

    command :list, list_desc, :options => list_options do
        helper.list_pool(options)
    end

    show_desc = <<-EOT.unindent
        Shows information for the given User
    EOT

    command :show, show_desc, [:userid, nil],
            :options => [OpenNebulaHelper::FORMAT, OpenNebulaHelper::DECRYPT] do
        user = args[0] || OpenNebula::User::SELF
        helper.show_resource(user, options)
    end

    show_desc = <<-EOT.unindent
        Encodes user and password to use it with ldap
    EOT

    command :encode, show_desc, :username, [:password, nil] do
        ar = args.compact

        if defined?(URI::Parser)
            parser = URI::DEFAULT_PARSER
        else
            parser = URI
        end

        puts ar.map {|a| parser.escape(a) }.join(':')

        0
    end

    passwdsearch_desc = <<-EOT.unindent
        Searches for users with a specific auth driver that has the given
        string in their password field
    EOT

    command :passwdsearch, passwdsearch_desc, :driver, :password,
            :options => [CLIHelper::CSV_OPT, OpenNebulaHelper::XML] do
        options[:list] = ['ID', 'NAME', 'AUTH', 'PASSWORD']
        options[:filter] = ["AUTH=#{args[0]}", "PASSWORD=#{args[1]}"]

        helper.list_pool(options)
    end

    token_add_desc = <<-EOT.unindent
        Creates the login token for authentication. The token can be used
        together with any authentication driver. The token will be stored in
        $HOME/.one/one_auth, and can be used subsequently to authenticate with
        oned through API, CLI or Sunstone.

        If <username> is ommited, it will infer it from the ONE_AUTH file.

        Example, request a valid token for a generic driver (e.g. core auth, LDAP...):
          oneuser token-create my_user --time 3600

        Example, request a group specific token (new resources will be created in that
        group and only resources that belong to that group will be listed):
          oneuser token-create my_user --group <id|group>

        Example, generate and set a token for SSH based authentication:
          oneuser token-create my_user --ssh --key /tmp/id_rsa --time 72000

        Example, same using X509 certificates:
          oneuser token-create my_user --x509 --cert /tmp/my_cert.pem
                                --key /tmp/my_key.pk --time 72000

        Example, now with a X509 proxy certificate
          oneuser token-create my_user --x509_proxy --proxy /tmp/my_cert.pem
                                --time 72000
    EOT

    command :"token-create", token_add_desc, [:username, nil],
            :options => login_options do
        helper.token_create(args, options)
    end

    token_set_desc = <<-EOT.unindent
        Generates a ONE_AUTH file that contains the token.

        You must provide one (and only one) of the following options:

        --token <token>    searches for a token that starts with that string. It must be
                           unique

        --group <id|group> returns the most durable token that provides access to that
                           specific group.

        --global           returns the most durable global token (non group specific).

        The argument 'username' is optional, if omitted it is inferred from the ONE_AUTH
        file.

        Example, set a token:
          $ oneuser token-set my_user --token 1d47
          export ONE_AUTH=/var/lib/one/.one/<file>.token; export ONE_EGID=-1

        You can copy & paste the output of the command and will load the proper
        environment variables.
    EOT

    command :"token-set", token_set_desc, [:username, nil],
            :options => login_options + set_options do
        username = args[0]

        if username
            if username =~ /^\d+$/
                exit_with_code(
                    1,
                    'The argument should be the username, not the ID.'
                )
            end

            helper.client = helper.get_login_client(username, options)
        end

        user = helper.retrieve_resource(OpenNebula::User::SELF)

        rc = user.info
        if OpenNebula.is_error?(rc)
            puts rc.message
            exit_with_code 1, rc.message
        end

        # Process the options
        if options[:token]
            token_hint = options[:token]
            group = nil
        elsif options[:group]
            token_hint = nil
            group = options[:group]
        elsif options[:global]
            token_hint = nil
            group = -1
        else
            exit_with_code(
                1,
                "One of these options must be supplied:\n" \
                '[--token <token>] [--group <id|group>] [--global]'
            )
        end

        tokens = helper.find_token(user, token_hint, group, false)

        if tokens.empty?
            exit_with_code 1, 'No valid tokens found.'
        end

        if token_hint && tokens.length > 1
            msg = "More than one token starting with '#{token_hint}' found."
            exit_with_code 1, msg
        end

        token = tokens[0]['TOKEN']

        egid  = user["LOGIN_TOKEN[TOKEN='#{token}']/EGID"]

        auth_string = "#{user['NAME']}:#{token}"
        auth_file   = helper.auth_file(auth_string)

        FileUtils.mkdir_p(File.dirname(auth_file)) rescue Errno::EEXIST

        file = File.open(auth_file, 'w')
        file.write(auth_string)
        file.close

        File.chmod(0o0600, auth_file)

        msg = 'export ONE_AUTH=' + auth_file
        msg << "; export ONE_EGID=#{egid}" if egid

        exit_with_code 0, msg
    end

    token_delete_desc = <<-EOT.unindent
        Expires a token and removes the associated ONE_AUTH file if present.
    EOT

    command :"token-delete", token_delete_desc, [:username, nil], :token,
            :options => login_options do
        if args.length == 1
            token_hint = args[0]
        else
            username, token_hint = args
        end

        if username
            helper.client = helper.get_login_client(username, options)
        end

        user = helper.retrieve_resource(OpenNebula::User::SELF)

        rc = user.info
        if OpenNebula.is_error?(rc)
            puts rc.message
            exit_with_code 1, rc.message
        end

        token = helper.find_token(user, token_hint, nil, true)

        if token.count > 1
            exit_with_code(
                1,
                "More than one token starting with '#{token_hint}' found."
            )
        elsif token.count.zero?
            exit_with_code 1, 'No tokens found.'
        end

        token = token[0]['TOKEN']

        rc = user.login(user['NAME'], token, 0)

        if OpenNebula.is_error?(rc)
            puts rc.message
            exit_with_code 1, rc.message
        else
            puts 'Token removed.'
        end

        auth_string = "#{user['NAME']}:#{token}"
        auth_file   = helper.auth_file(auth_string)

        # rubocop:disable Lint/SuppressedException
        begin
            File.unlink(auth_file)
            puts "Removing #{auth_file}"
        rescue Errno::ENOENT
        end
        # rubocop:enable Lint/SuppressedException

        0
    end

    token_delete_all = <<-EOT.unindent
        Delete all the tokens of a user. This command is intented to be executed by a
        user that has MANAGE permissions of the target user.
    EOT

    command :"token-delete-all", token_delete_all, :username,
            :options => login_options do
        username = args[0]

        if username =~ /^\d+$/
            exit_with_code 1, 'The argument should be the username, not the ID.'
        end

        helper.perform_action(username, options, 'Tokens expired') do |user|
            user.login(username, '', 0)
        end
    end

    enable_desc = <<-EOT.unindent
        Enables the given User
    EOT

    command :enable, enable_desc, [:range, :userid_list] do
        helper.perform_actions(args[0], options, 'enabled') do |user|
            user.enable
        end
    end

    disable_desc = <<-EOT.unindent
        Disables the given User
    EOT

    command :disable, disable_desc, [:range, :userid_list] do
        helper.perform_actions(args[0], options, 'disabled') do |user|
            user.disable
        end
    end
end
